spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1import type { Metadata } from 'next';2import { notFound, redirect } from 'next/navigation';3import { t } from '@/i18n';4import { api, isNotBuilt, safe } from '@/lib/api';5import { apiCompare } from '@/lib/api-compare';6import { MIN_COMPARE_COUNTRIES, compareCanonicalQuery, parseCompareState, splitCompareSlugs, type CompareState } from '@/lib/compare-state';7import { routes } from '@/lib/site';8import { topicById } from '@/lib/topics';9import type { CountrySummary, IndicatorCard } from '@/lib/types';10import { apiModeOf, toCountryLite, type CompareSeries, type CompareSnapshotRow, type CountryLite } from '@/lib/types-compare';11import { seoTitle } from '@/lib/seo';12import { HeadToHead } from '@/components/compare/head-to-head';13import { CompareChart } from '@/components/compare/compare-chart';14import { ChartGrid, ChartSections } from '@/components/compare/chart-sections';15import { CompareControls } from '@/components/compare/compare-controls';16import { CustomPanel } from '@/components/compare/custom-panel';17import { SnapshotTable } from '@/components/compare/snapshot-table';18import { NotBuiltState } from '@/components/data/empty-state';19import { Section } from '@/components/data/section';2021export const revalidate = 900;2223type Params = { slugs: string[] };24type SP = Record<string, string | string[] | undefined>;25const EAGER = 4; // charts rendered with server-fetched series; the rest fetch on scroll2627/** Resolve path segments (slugs or ISO3) against the country list, preserving order; null when the API is not built. */28async function resolve(segments: string[]): Promise<{ countries: CountrySummary[]; all: CountrySummary[] } | 'not-built'> {29 let list;30 try {31 list = await api.countries();32 } catch (e) {33 if (isNotBuilt(e)) return 'not-built';34 throw e;35 }36 const bySlug = new Map<string, CountrySummary>();37 for (const c of list.items) {38 if (c.slug) bySlug.set(c.slug.toLowerCase(), c);39 bySlug.set(c.id.toLowerCase(), c);40 }41 const seen = new Set<string>();42 const countries: CountrySummary[] = [];43 for (const s of splitCompareSlugs(segments)) {44 const c = bySlug.get(s);45 if (c && !seen.has(c.id)) {46 seen.add(c.id);47 countries.push(c);48 }49 }50 return { countries, all: list.items };51}5253function namesOf(cs: CountrySummary[]): string {54 return cs.map((c) => c.name ?? c.id).join(` ${t('compare.vs')} `);55}5657export async function generateMetadata({ params, searchParams }: { params: Promise<Params>; searchParams: Promise<SP> }): Promise<Metadata> {58 const [{ slugs }, sp] = await Promise.all([params, searchParams]);59 const r = await resolve(slugs);60 if (r === 'not-built' || r.countries.length < MIN_COMPARE_COUNTRIES) return { title: t('compare.notFound'), robots: { index: false } };61 const state = parseCompareState(sp);62 const names = namesOf(r.countries);63 const tabName = state.tab === 'snapshot' ? null : state.tab === 'custom' ? t('compare.tab.custom') : topicById(state.tab)?.name ?? state.tab;64 const title = tabName ? `${seoTitle.compare(r.countries.map((c) => c.name ?? c.id))} — ${tabName}` : seoTitle.compare(r.countries.map((c) => c.name ?? c.id));65 const description = t('compare.pageDescription', { names, list: 'GDP, GDP per capita, growth, inflation, unemployment, life expectancy' });66 const canonical = `${routes.compare(...r.countries.map((c) => c.slug ?? c.id))}${compareCanonicalQuery(state)}`;67 return {68 title,69 description,70 alternates: { canonical },71 robots: state.tab === 'custom' ? { index: false, follow: true } : undefined,72 openGraph: { title: `${title} — ${t('site.name')}`, description, url: canonical, type: 'article', images: [{ url: routes.compareOg(r.countries.map((c) => c.slug ?? c.id)), width: 1200, height: 630, alt: names }] },73 twitter: { card: 'summary_large_image', title, description, images: [routes.compareOg(r.countries.map((c) => c.slug ?? c.id))] },74 };75}7677export default async function CompareViewPage({ params, searchParams }: { params: Promise<Params>; searchParams: Promise<SP> }) {78 const [{ slugs: segments }, sp] = await Promise.all([params, searchParams]);79 const r = await resolve(segments);80 if (r === 'not-built') return <NotBuiltState />;81 // A single country (e.g. the "Compare" action of a country page) opens the builder with it pre-selected.82 if (r.countries.length === 1) redirect(`${routes.compare()}?c=${r.countries[0]!.slug ?? r.countries[0]!.id.toLowerCase()}`);83 if (r.countries.length < MIN_COMPARE_COUNTRIES) notFound();8485 let state: CompareState = parseCompareState(sp);86 // `?indicator=` without a tab → custom tab with that single indicator (the snapshot table always sets a tab).87 if (state.indicator && state.tab === 'snapshot') state = { ...state, tab: 'custom', indicators: Array.from(new Set([state.indicator, ...state.indicators])).slice(0, 6) };8889 const countries: CountryLite[] = r.countries.map(toCountryLite);90 const all: CountryLite[] = r.all.filter((c) => c.kind !== 'aggregate').map(toCountryLite).sort((a, b) => a.name.localeCompare(b.name));91 const ids = countries.map((c) => c.id);92 const slugs = countries.map((c) => c.slug);93 const names = namesOf(r.countries);9495 // Data for the active tab96 const topic = state.tab !== 'snapshot' && state.tab !== 'custom' ? state.tab : null;97 const customSlugs = state.tab === 'custom' ? state.indicators : [];98 const snapshotP = state.tab === 'custom' ? (customSlugs.length ? safe(apiCompare.snapshot(ids, { indicators: customSlugs })) : Promise.resolve(null)) : safe(apiCompare.snapshot(ids, { topic }));99 const heroP = state.indicator && state.tab !== 'snapshot' ? safe(apiCompare.compare(ids, [state.indicator], { from: state.from, to: state.to, mode: apiModeOf(state.mode) })) : Promise.resolve(null);100 const [snapshot, hero] = await Promise.all([snapshotP, heroP]);101102 const rows: CompareSnapshotRow[] = (snapshot?.rows ?? []).filter((row) => ids.some((id) => row.values[id]?.has_data));103 const maxYear = Math.max(new Date().getUTCFullYear(), ...rows.flatMap((row) => Object.values(row.values).map((v) => v.year ?? 0)));104105 // Eager series for the first charts of a chart tab (one request, ≤ 8 indicators).106 let eager: Map<string, CompareSeries[]> = new Map();107 if (topic || state.tab === 'custom') {108 const eagerSlugs = rows109 .map((row) => row.indicator.slug)110 .filter((s) => s !== state.indicator)111 .slice(0, state.tab === 'custom' ? 6 : EAGER);112 if (eagerSlugs.length) {113 const bundle = await safe(apiCompare.compare(ids, eagerSlugs, { from: state.from, to: state.to, mode: apiModeOf(state.mode) }));114 if (bundle) {115 eager = new Map(eagerSlugs.map((s) => [s, bundle.series.filter((x) => x.indicator.slug === s)]));116 }117 }118 }119120 const heroCard: IndicatorCard | null = hero?.indicators[0] ?? rows.find((row) => row.indicator.slug === state.indicator)?.indicator ?? null;121 const heroRow = rows.find((row) => row.indicator.slug === state.indicator) ?? null;122 const gridRows = rows.filter((row) => row.indicator.slug !== state.indicator);123 const downloadIndicators = rows.map((row) => row.indicator.slug).slice(0, 40);124 const downloadHref = downloadIndicators.length ? routes.compareDownload(ids, downloadIndicators, { from: state.from, to: state.to }) : null;125 const topicDef = topic ? topicById(topic) : null;126 const tabLabel = state.tab === 'snapshot' ? t('compare.tab.snapshot') : state.tab === 'custom' ? t('compare.tab.custom') : (topicDef?.name ?? state.tab);127128 return (129 <>130 <header className="pb-3 pt-5 md:pt-8">131 <div className="eyebrow">{t('compare.title')}</div>132 <h1 className="display mt-1 text-2xl leading-tight text-ink md:text-4xl">133 {r.countries.map((c, i) => (134 <span key={c.id}>135 {i > 0 ? <span className="mx-1.5 text-ink-3">{t('compare.vs')}</span> : null}136 <span aria-hidden>{c.flag} </span>137 {c.name}138 </span>139 ))}140 </h1>141 <p className="sr-only">{t('compare.headingTab', { names, tab: tabLabel })}</p>142 <p className="mt-2 text-xs text-ink-3">{t('compare.colourNote')}</p>143 </header>144145 <CompareControls countries={countries} allCountries={all} state={state} maxYear={maxYear} downloadHref={downloadHref} />146147 {state.tab === 'snapshot' && countries.length === 2 ? (148 <Section id="h2h" title={t('compare.h2h.title')} subtitle={t('compare.h2h.sub')} className="border-t-0">149 <HeadToHead rows={rows} countries={countries} slugs={slugs} state={state} />150 </Section>151 ) : null}152 {state.tab === 'snapshot' ? (153 <Section id="snapshot" title={t('compare.snapshot.title')} subtitle={t('compare.snapshot.sub')} className={countries.length === 2 ? undefined : 'border-t-0'}>154 <SnapshotTable rows={rows} countries={countries} slugs={slugs} state={state} />155 </Section>156 ) : null}157158 {state.tab !== 'snapshot' ? (159 <>160 {state.tab === 'custom' ? (161 <Section id="custom" title={t('compare.custom.title')} subtitle={t('compare.custom.sub', { max: 6 })} className="border-t-0 pb-4 md:pb-6">162 <CustomPanel selected={rows.map((row) => row.indicator)} state={state} />163 </Section>164 ) : null}165166 {state.indicator && heroCard ? (167 <div className={state.tab === 'custom' ? 'pb-6' : 'pb-6 pt-6 md:pt-8'}>168 <CompareChart indicator={heroCard} countries={countries} state={state} initial={hero?.series ?? null} snapshot={heroRow?.values ?? null} hero maxYear={maxYear} />169 </div>170 ) : null}171172 {topic ? (173 <Section id={`charts-${topic}`} title={t('compare.charts.title', { topic: topicDef?.name ?? topic })} subtitle={gridRows.length ? t('compare.charts.sub', { n: rows.length }) : undefined} className={state.indicator ? undefined : 'border-t-0'}>174 {gridRows.length === 0 ? (175 <p className="py-6 text-sm text-ink-3">{t('compare.charts.none', { topic: (topicDef?.short ?? topic).toLowerCase() })}</p>176 ) : (177 <ChartSections rows={gridRows} countries={countries} slugs={slugs} state={state} eager={eager} maxYear={maxYear} anchorPrefix={`sub-${topic}`} />178 )}179 </Section>180 ) : null}181182 {state.tab === 'custom' && gridRows.length ? (183 <div className="pb-10">184 <ChartGrid rows={gridRows} countries={countries} state={state} eager={eager} maxYear={maxYear} />185 </div>186 ) : null}187188 {rows.length ? (189 <Section id="snapshot-tab" title={t('compare.snapshot.title')} subtitle={topicDef ? t('compare.snapshot.subTopic', { topic: topicDef.name }) : t('compare.snapshot.sub')} level={3}>190 <SnapshotTable rows={rows} countries={countries} slugs={slugs} state={state} />191 </Section>192 ) : null}193 </>194 ) : null}195 </>196 );197}198